1 tokenizer
1.1 概述
文本分词的过程涉及将文本拆分成多个单词或子单词。接着,这些单词或子单词会被映射到特定的ID,转换过程涉及一个查找表,这是一种简单的对应关系
因此,我们的主要关注点在于解析文本为一系列的单词或子单词
更具体地说,我们将探讨🤗 Transformers库中常用的三种主要分词器类型:Byte-Pair Encoding (BPE)、WordPiece和SentencePiece,并且我们将提供实例说明哪种模型采用了哪种分词器
要了解特定预训练模型使用了哪种分词器,你可以参考每个模型主页上的文档说明,例如BertTokenizer,你会发现模型采用的是WordPiece分词器
1.2 分词例子
将一段文本分词到小块是一个比它看起来更加困难的任务,并且有很多方式来实现分词,举个例子,让我们看看这个句子
"Don't you love 🤗 Transformers? We sure do."
对这段文本分词的一个简单方式,就是使用空格来分词,得到的结果是:
["Don't", "you", "love", "🤗", "Transformers?", "We", "sure", "do."]
上面的分词是一个明智的开始,但是如果我们查看token "Transformers?"
和 "do."
,我们可以观察到标点符号附在单词"Transformer"
和 "do"
的后面,这并不是最理想的情况
我们应该将标点符号考虑进来,这样一个模型就没必要学习一个单词和每个可能跟在后面的 标点符号的不同的组合,这么组合的话,模型需要学习的组合的数量会急剧上升。将标点符号也考虑进来,对范例文本进行分词的结果就是:
["Don", "'", "t", "you", "love", "🤗", "Transformers", "?", "We", "sure", "do", "."]
分词的结果更好了,然而,这么做也是不好的,分词怎么处理单词"Don't"
,"Don't"
的含义是"do not"
,所以这么分词["Do", "n't"]
会更好
现在开始事情就开始变得复杂起来了,部分的原因是每个模型都有它自己的分词类型
依赖于我们应用在文本分词上的规则, 相同的文本会产生不同的分词输出
用在训练数据上的分词规则,被用来对输入做分词操作,一个预训练模型才会正确的执行
spaCy and Moses 是两个受欢迎的基于规则的分词器
,将这两个分词器应用在示例文本上,spaCy 和 Moses会输出类似下面的结果:
["Do", "n't", "you", "love", "🤗", "Transformers", "?", "We", "sure", "do", "."]
可见上面的分词使用到了空格和标点符号的分词方式,以及基于规则的分词方式
空格和标点符号分词以及基于规则的分词都是单词分词的例子,不那么严格的来说,单词分词的定义就是将句子分割到很多单词
然而将文本分割到更小的块是符合直觉的,当处理大型文本语料库时,上面的 分词方法会导致很多问题
在这种情况下,空格和标点符号分词通常会产生一个非常大的词典(使用到的所有不重复的单词和tokens的集合)
像:Transformer XL使用空格和标点符号分词,结果会产生一个大小是267,735的词典
这么大的一个词典容量,迫使模型有着一个巨大的embedding矩阵,以及巨大的输入和输出层,这会增加内存使用量,也会提高时间复杂度
通常情况下,transformers模型几乎没有词典容量大于50,000的,特别是只在一种语言上预训练的模型
所以如果简单的空格和标点符号分词让人不满意,为什么不简单的对字符分词
尽管字符分词是非常简单的,并且能极大的减少内存使用,降低时间复杂度,但是这样做会让模型很难学到有意义的输入表达
像: 比起学到单词"today"
的一个有意义的上下文独立的表达,学到字母"t"
的一个有意义的上下文独立的表达是相当困难的
因此,字符分词经常会伴随着性能的下降。所以为了获得最好的结果,transformers模型在单词级别分词和字符级别分词之间使用了一个折中的方案被称作子词分词
1.3 分词粒度
在NLP中,模型如Bert、GPT)的输入通常需要先进行tokenize,其目的是将输入的文本流,切分为一个个子串,每个子串都有完整的语义,便于学习embedding表达和后续模型的使用。tokenize有三种粒度:word/subword/char
word/词:词是最自然的语言单元,对于英文来说其天然存在空格进行,切分相对容易,常用的分词器有spaCy和Moses
中文不具备这样的分割符,所以相对困难一些,不过目前也有Jieba、HanLP、LTP等分词器,这些分词器基于规则与模型,可以取得良好的分词效果
使用词时会有2个问题,通常情况下词表大小不超过5w:
- 词表通常是基于语料进行分词获得,但遇到新的语料时可能会出现OOV的情况
- 词表过于庞大,对于模型来说大部分参数都集中在输入输出层,不利于模型学习,且容易爆内存(显存)
char/字符:字符是一种语言最基本的组成单元,如英文中的'a'、'b'、'c'或中文中的‘你’、‘我’、‘他’等,使用字符有如下问题:
- 字符数量是有限的通常数量较少,这样在学习每个字符的embedding向量时,每个字符中包含非常多的语义,学习起来比较困难
- 以字符分割,会造成序列长度过长,对后续应用造成较大限制
subword/子词:它介于char和word之间,可以很好的平衡词汇量和语义独立性,它的切分准则是常用的词不被切分,而不常见的词切分为子词
2 子词分词
子词分词原则
子词分词算法依赖这样的原则:
- 频繁使用的单词不应该被分割成更小的子词
- 很少使用的单词应该被分解到有意义的子词
举个例子: "annoyingly"
能被看作一个很少使用的单词,能被分解成"annoying"
和"ly"
"annoying"
和"ly"
作为独立地子词,出现的次数都很频繁,而且与此同时单词"annoyingly"
的含义可以通过组合"annoying"
和"ly"
的含义来获得
在粘合和胶水语言上,像Turkish语言,这么做是相当有用的,在这样的语言里,通过线性组合子词,大多数情况下你能形成任意长的复杂的单词
子词分词允许模型有一个合理的词典大小,而且能学到有意义的上下文独立地表达
除此以外,子词分词可以让模型处理以前从来没见过的单词, 方式是通过分解这些单词到已知的子词,举个例子:BertTokenizer
对句子"I have a new GPU!"
分词的结果如下:
>>> from transformers import BertTokenizer
>>> tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
>>> tokenizer.tokenize("I have a new GPU!")
["i", "have", "a", "new", "gp", "##u", "!"]
因为我们正在考虑不区分大小写的模型,句子首先被转换成小写字母形式
我们可以见到单词["i", "have", "a", "new"]
在分词器的词典内,但是这个单词"gpu"
不在词典内
所以,分词器将"gpu"
分割成已知的子词["gp" and "##u"]
"##"
意味着剩下的 token应该附着在前面那个token的后面,不带空格的附着(分词的解码或者反向)
另外一个例子,XLNetTokenizer
对前面的文本例子分词结果如下:
from transformers import XLNetTokenizer
tokenizer = XLNetTokenizer.from_pretrained("xlnet-base-cased")
tokenizer.tokenize("Don't you love 🤗 Transformers? We sure do.")
>>> ["▁Don", "'", "t", "▁you", "▁love", "▁", "🤗", "▁", "Transform", "ers", "?", "▁We", "▁sure", "▁do", "."]
当我们查看SentencePiece时会回过头来解释这些"▁"
符号的含义。正如你能见到的,很少使用的单词 "Transformers"
能被分割到更加频繁使用的子词"Transform"
和"ers"
现在让我们来看看不同的子词分割算法是怎么工作的,注意到所有的这些分词算法依赖于某些训练的方式,这些训练通常在语料库上完成, 相应的模型也是在这个语料库上训练的
2.1 Byte-Pair Encoding (BPE)
BPE-Neural Machine Translation of Rare Words with Subword Units (Sennrich et al., 2015)
BPE依赖于一个预分词器,这个预分词器会将训练数据分割成单词。预分词可以是简单的 空格分词,像:GPT-2,RoBERTa
更加先进的预分词方式包括了基于规则的分词,像:XLM,FlauBERT,FlauBERT在大多数语言使用了Moses,或者GPT,GPT使用了Spacy和ftfy,统计了训练语料库中每个单词的频次
在预分词以后,生成了单词的集合,也确定了训练数据中每个单词出现的频次
下一步,BPE产生了一个基础词典,包含了集合中所有的符号,BPE学习融合的规则-组合基础词典中的两个符号来形成一个新的符号
BPE会一直学习直到词典的大小满足了期望的词典大小的要求。注意到 期望的词典大小是一个超参数,在训练这个分词器以前就需要人为指定
举个例子,让我们假设在预分词以后,下面的单词集合以及他们的频次都已经确定好了:
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
所以,基础的词典是["b", "g", "h", "n", "p", "s", "u"]
,将所有单词分割成基础词典内的符号,就可以获得:
("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)
BPE接着会统计每个可能的符号对的频次,然后挑出出现最频繁的的符号对,在上面的例子中,"h"
跟了"u"
出现了10 + 5 = 15次 (10次是出现了10次"hug"
,5次是出现了5次"hugs"
)
然而,最频繁的符号对是"u"
后面跟了个"g"
,总共出现了10 + 5 + 5 = 20次
因此,分词器学到的第一个融合规则是组合所有的"u"
后面跟了个"g"
符号
下一步,"ug"
被加入到了词典内。单词的集合就变成了:
("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)
BPE接着会统计出下一个最普遍的出现频次最大的符号对,也就是"u"
后面跟了个"n"
,出现了16次,"u"
,"n"
被融合成了"un"
。
也被加入到了词典中,再下一个出现频次最大的符号对是"h"
后面跟了个"ug"
,出现了15次
又一次这个符号对被融合成了"hug"
, 也被加入到了词典中
在当前这步,词典是["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"]
,我们的单词集合则是:
("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)
假设,the Byte-Pair Encoding在这个时候停止训练,学到的融合规则并应用到其他新的单词上(只要这些新单词不包括不在基础词典内的符号 就行)
举个例子,单词"bug"
会被分词到["b", "ug"]
,但是"mug"
会被分词到["<unk>", "ug"]
,因为符号"m"
不在基础词典内
通常来看的话,单个字母像"m"
不会被"<unk>"
符号替换掉,因为训练数据通常包括了每个字母,每个字母至少出现了一次,但是在特殊的符号 中也可能发生像emojis
就像之前提到的那样,词典的大小,举个例子,基础词典的大小 + 融合的数量,是一个需要配置的超参数
举个例子:GPT 的词典大小是40,478,因为GPT有着478个基础词典内的字符,在40,000次融合以后选择了停止训练
2.1.1 Byte-level BPE
一个包含了所有可能的基础字符的基础字典可能会非常大,如果考虑将所有的unicode字符作为基础字符
为了拥有一个更好的基础词典,GPT-2使用了字节 作为基础词典,这是一个非常聪明的技巧,迫使基础词典是256大小,而且确保了所有基础字符包含在这个词典内。使用了其他的规则来处理标点符号,这个GPT2的分词器能对每个文本进行分词,不需要使用到
2.2 WordPiece
WordPiece是子词分词算法,被用在BERT,DistilBERT,和Electra,和BPE非常相似
WordPiece首先初始化一个词典,这个词典包含了出现在训练数据中的每个字符,然后递进的学习一个给定数量的融合规则
和BPE相比较, WordPiece不会选择出现频次最大的符号对,而是选择了加入到字典以后能最大化训练数据似然值的符号对
所以这到底意味着什么?参考前面的例子,最大化训练数据的似然值,等价于找到一个符号对,它们的概率除以这个符号对中第一个符号的概率,接着除以第二个符号的概率,在所有的符号对中商最大
像:如果"ug"
的概率除以"u"
除以"g"
的概率的商,比其他任何符号对更大, 这个时候才能融合"u"
和"g"
直觉上,WordPiece,和BPE有点点不同,WordPiece是评估融合两个符号会失去的量,来确保这么做是值得的
2.3 Unigram
Unigram是一个子词分词器算法,和BPE或者WordPiece相比较 ,Unigram使用大量的符号来初始化它的基础字典,然后逐渐的精简每个符号来获得一个更小的词典。举例来看基础词典能够对应所有的预分词 的单词以及最常见的子字符串。Unigram没有直接用在任何transformers的任何模型中,但是和SentencePiece一起联合使用。
在每个训练的步骤,Unigram算法在当前词典的训练数据上定义了一个损失函数(经常定义为log似然函数的),还定义了一个unigram语言模型。 然后,对词典内的每个符号,算法会计算如果这个符号从词典内移除,总的损失会升高多少
Unigram然后会移除百分之p的符号,这些符号的loss 升高是最低的(p通常是10%或者20%),像:这些在训练数据上对总的损失影响最小的符号
重复这个过程,直到词典已经达到了期望的大小。 为了任何单词都能被分词,Unigram算法总是保留基础的字符
因为Unigram不是基于融合规则(和BPE以及WordPiece相比较),在训练以后算法有几种方式来分词,如果一个训练好的Unigram分词器 的词典是这个:
["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"],
"hugs"
可以被分词成["hug", "s"]
, ["h", "ug", "s"]
或者["h", "u", "g", "s"]
所以选择哪一个呢?Unigram在保存词典的时候还会保存训练语料库内每个token的概率,所以在训练以后可以计算每个可能的分词结果的概率
实际上算法简单的选择概率最大的那个分词结果,但是也会提供概率来根据分词结果的概率来采样一个可能的分词结果
分词器在损失函数上训练,这些损失函数定义了这些概率
假设训练数据包含了这些单词 ,一个单词的所有可能的分词结果的集合定义为,然后总的损失就可以定义为:
2.4 SentencePiece
目前为止描述的所有分词算法都有相同的问题:它们都假设输入的文本使用空格来分开单词,然而,不是所有的语言都使用空格来分开单词
一个可能的解决方案是使用某种语言特定的预分词器。像:XLM使用了一个特定的中文、日语和Thai的预分词器
为了更加广泛的解决这个问题,SentencePiece
将输入文本看作一个原始的输入流,因此使用的符合集合中也包括了空格
SentencePiece然后会使用BPE或者unigram算法来产生合适的词典
举例来说,XLNetTokenizer
使用了SentencePiece,这也是为什么上面的例子中"▁"
符号包含在词典内
SentencePiece解码是非常容易的,因为所有的tokens能被concatenate起来,然后将"▁"
替换成空格
库内所有使用了SentencePiece的transformers模型,会和unigram组合起来使用,像:使用了SentencePiece的模型是ALBERT, XLNet,Marian,和T5
3 训练分词器
当前,预训练语言模型已成为NLP算法工程师的工具箱中的常客。在实际应用中,几乎所有的NLP模型都依赖于分词器(Tokenizer)来处理文本数据
虽然通常我们会倾向于使用现成的分词器,但有时候创建一个定制化的分词器也是必要的
对于分词器的构建,通常可以选择使用sentencepiece或者huggingface的tokenizers库,我们可以采用tokenizers库来训练我们自己的分词器,确保tokenizers库已经安装在你的系统上
pip install tokenizers
有了tokenizers库,我们可以开始构建我们的Tokenizer。这个过程包括配置多个组件以自定Tokenizer的行为,包括但不限于:
- 模型(Models):这是Tokenizer的核心,负责实际的分词操作。可选的模型包括WordLevel、BPE、Unigram和WordPiece
- 规范化器(Normalizers):规范化器用于预处理输入文本,将其转换为标准化的格式,如进行Unicode规范化或转换为小写,同时跟踪与原始文本的对齐关系
- 预分词器(PreTokenizers):预分词器按照一定规则拆分输入文本,以确保底层模型按照这些预设边界构建令牌
- 后处理器(PostProcessors):在分词流程完成后,后处理器负责在标记化后的字符串中插入特殊标记,比如说为模型提供标准格式的字符串
- 解码器(Decoders):解码器能够将分词器生成的ID转换回人类可读的文本
这些组件的组合使得Tokenizer不仅能够执行基本的分词任务,还能为特定的NLP问题提供定制化的解决方案
在调用 Tokenizer.encode 或 Tokenizer.encode_batch 时,输入文本将经过以下流程:
- 规范化
- 预分词
- 模型处理
- 后处理
#!/usr/bin/env Python
# -- coding: utf-8 --
"""
@version: v1.0
@author: huangyc
@file: train_tokenizer.py
@Description:
@time: 2024/2/15 9:42
"""
from datasets import load_dataset
from tokenizers.models import BPE
from tokenizers.normalizers import NFD, StripAccents
from tokenizers.pre_tokenizers import Whitespace, Punctuation, Digits, ByteLevel
from tokenizers.trainers import BpeTrainer
from tokenizers import Tokenizer, normalizers, pre_tokenizers, decoders
from tokenizers import tokenizers
from tokenizers.processors import TemplateProcessing
def batch_iterator(batch_size=10000):
dataset = load_dataset("TurboPascal/tokenizers_example_zh_en", cache_dir='./cache/')
print(dataset)
for i in range(0, len(dataset), batch_size):
yield dataset['train'][i: i + batch_size]["text"]
def train_tokenizer():
# 自定数据集
data_files = [r".\data\dataset_hyc.txt"]
# 定义tokenizer
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
# 定义一个归一化对象
normalizer = normalizers.Sequence([NFD(), StripAccents()])
tokenizer.normalizer = normalizer
# 我们主要使用四类分割,空白、标点符号、数字、Bytelevel
# pre_tokenizer = pre_tokenizers.Sequence([Whitespace(), Punctuation(), Digits(individual_digits=True), ByteLevel()])
# tokenizer.pre_tokenizer = pre_tokenizer
# 使用空白分割
tokenizer.pre_tokenizer = Whitespace()
# 解码器
tokenizer.decoder = decoders.ByteLevel(add_prefix_space=True, use_regex=True)
# 字节级 BPE 可能在生成的令牌中包括空白。如果您不希望偏移量包含这些空格,那么必须使用这个 PostProcessor。
tokenizer.post_processor = tokenizers.processors.ByteLevel()
# 定义一个BpeTrainer
trainer = BpeTrainer(special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"])
# 开始训练
# 方式一
tokenizer.train(data_files, trainer)
# 方式二
# tokenizer.train_from_iterator(batch_iterator(), trainer=trainer, length=len(dataset['train']))
# tokenizer保存
tokenizer.save("data/tokenizer-wiki.json")
# tokenizer加载
tokenizer = Tokenizer.from_file("data/tokenizer-wiki.json")
sentence = "我尝试了很多的方法"
output = tokenizer.encode(sentence)
print(output.tokens)
# ['我', '[UNK]', '[UNK]', '了', '很', '多的', '方', '法']
print(output.ids)
# [376, 0, 0, 44, 339, 1561, 438, 524]
print(output.offsets[5])
# (5, 7)
print(sentence[output.offsets[5][0]:output.offsets[5][1]])
# '多的'
tokenizer.token_to_id("[SEP]")
# 2
# 后续处理
tokenizer.post_processor = TemplateProcessing(single="[CLS] $A [SEP]", pair="[CLS] $A [SEP] $B:1 [SEP]:1",
special_tokens=[("[CLS]", tokenizer.token_to_id("[CLS]")),
("[SEP]", tokenizer.token_to_id("[SEP]")), ], )
output = tokenizer.encode_batch([sentence])
print(output)
output = tokenizer.encode_batch([["我尝试了许多的方法", "却始终没有成功"], ["自己说过的话", "就必须要努力去践行"]])
print(output)
tokenizer.enable_padding(pad_id=3, pad_token="[PAD]")
output = tokenizer.encode_batch(["我尝试了许多的方法", "却始终没有成功"])
print(output[0].tokens, output[1].tokens)
# ['[CLS]', '我', '[UNK]', '[UNK]', '了', '许', '多的', '方', '法', '[SEP]'] ['[CLS]', '[UNK]', '[UNK]', '[UNK]', '没有', '成', '功', '[SEP]', '[PAD]', '[PAD]']
print(output[1].attention_mask)
# [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]
if __name__ == '__main__':
train_tokenizer()
3.1 扩展
qwen扩展自已的词表